Skip to content

fix(plugin-auth): create-user reports the membership the store holds for the new account - #33587

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/area-A1-33339
Nov 15, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/area-A1-33339

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #33339

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:管理员创建用户接口(POST /api/v1/auth/admin/create-user)的返回值与审计行里的 membershipCreated,现在按"新账号在调用结束时是否已有组织成员关系"来回答,不再只看是不是本接口自己插入的那一行。
  • 为什么改:单组织部署下,用户创建钩子(默认组织自动加入)先一步写入了 sys_member,接口自己的补绑看到已有行就让路,于是回答 false;数据库里明明有成员关系,管理员却被告知"创建了一个无组织的账号"。
  • 风险与回滚:只改一个布尔字段在两种已有分支下的取值(让路 / 多组织下已存在的行),不改写入、不改权限;回滚即还原 admin-user-endpoints.ts 两行。仅邀请制(invite-only)仍回答 false、不写成员行,测试钉住未变。
  • 先看:packages/plugins/plugin-auth/src/admin-user-endpoints.ts(bindUserToSoleOrganization 的返回行与 joinCurrentOrganization 的已存在分支);packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts(两条改写的钉子)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/admin-user-endpoints.test.ts,预期 100 条全绿,其中 "the bootstrap join already bound the new user" 一条断言返回与审计行都是 true。
  • 需要你做的:无,席位落地。

What changes

membershipCreated, in the create-user answer and in the user.admin_created audit row, now reports that the store holds a membership for the new account after the call, whichever writer made the row. The account did not exist before this call, so any membership it holds was created by this creation.

  • Single-org posture (bindUserToSoleOrganization): membershipCreated is outcome === 'bound' || outcome === 'yielded'. A yielded outcome is the reconciler reading an existing sys_member row for the user; on create-user that row is the user.create.after bootstrap join's, written moments earlier inside the same createUser. This is the same reading the sign-up hook already makes (auth-manager-database-hooks.ts treats bound and yielded alike as "the user holds a membership").
  • Multi-org posture (joinCurrentOrganization): the card asked whether the same reasoning applies to the "membership already there" branch. It does: the user is brand-new, so a row in the administrator's current organization can only have been written during this creation (a host's user.create.after hook). It is still respected (no second addMember) and is now reported as membershipCreated: true.
  • Unchanged, pinned as the control: invite-only on the single-org posture answers policy-skip, no organizationId, membershipCreated: false, no sys_member insert (the existing pin, unedited). failed, no-target-org and agent-account still answer false.
  • The jsdoc that stated the old rule (bindUserToSoleOrganization, AdminAuditDecisions.membershipCreated, joinCurrentOrganization) now states the new one.

Why two existing pins were rewritten. Both pinned yielded/already-there as false by seeding a sys_member row for user-9 before the call. For a brand-new account that seed can only stand for a row written during the creation, which is exactly the reported defect, so the old expectation pinned the bug.

  • is idempotent when a membership already exists becomes the bootstrap join already bound the new user: …: the row is now written by the createUser double itself (the hook's bind, during the creation), and the pin asserts true in the answer AND the audit row, plus no second sys_member insert.
  • a membership already there is respected: … membershipCreated false becomes a membership written during the creation is respected: … membershipCreated true …, and gains the audit-row assertion.

Item 2 of the card (business-unit member rows "not stamped"): premise does not hold on main (4bba8b8), so nothing is changed for it.

  • create-user writes no sys_business_unit_member row. git grep "'sys_business_unit_member'" -- 'packages/**/src/**/*.ts' ':!**/*.test.ts' at 4bba8b8 answers four hits: the object definition, the sys_user page's related list, the CLI ownership inventory and the platform-object name list. No runtime code inserts that object; plugin-auth only reads it (external-membership.ts).
  • The warning in the QA record is plugin-sharing's boot-time warnOnEmptyUnitExpansion (sharing-rule-service.ts): "active business-unit rule expands to NO recipients … membership rows written by seed replay or by an elevated system write are not organization-stamped". It names two causes worth checking first; it does not say rows were written. The rule it named, showcase_share_new_inquiries_with_field_ops (organization null), points at bu_field_ops, and the showcase seed writes the unit tree but, by design, no user-to-unit membership (examples/app-showcase/src/data/seed/index.ts: "Users can't be seeded … so user↔unit membership … stay runtime admin actions"). An empty expansion on a fresh showcase boot is the true answer, and the warning reports it correctly.

Tests

Run on the branch head before the PR opened (see Verification for the sha).

  • pnpm --filter @objectstack/plugin-auth test: Test Files 274 passed (274), Tests 5303 passed, 10 skipped (5313).
  • pnpm --filter @objectstack/plugin-auth typecheck (after building the plugin-auth closure): the first run went red only on check:test-typecheck reporting the debt SHRANK (admin-user-endpoints.test.ts: 6 errors, ledger 7, because the rewritten pin reads calls through callsOf); re-recorded with gen:test-typecheck-debt and committed.
  • eslint --no-inline-config on both changed source files: exit 0.

Ablation (the fix committed first, each mutation through scripts/ablation-replace.mjs, which verified the anchor 1 to 0 on disk and restored to the HEAD blob ee8f0ff9d8c6 with git diff HEAD empty; the test imports ./admin-user-endpoints.js from src, so no build sits between):

  • membershipCreated: result.outcome === 'bound', (the old rule): × the bootstrap join already bound the new user …, Tests 1 failed, 99 passed.
  • existing.length > 0) return { organizationId, membershipCreated: false }; (the old multi-org rule): × a membership written during the creation is respected …, Tests 1 failed, 99 passed.

Acceptance notes

  • Pins per behaviour: bootstrap join then endpoint (answer and audit true), this call bound (binds the created user to the sole organization, unchanged, true in both), invite-only control (unchanged, false, no insert, no org lookup), multi-org already-there (true in both).
  • A failed outcome after a unique-key race (another writer landed the pair between the reconciler's re-check and its insert) still answers false although the store then holds the row. Reading the store back after every create would close it at the cost of one more read per create; not done here, noted only.

Verification

Local derived gate subset (node scripts/pm/dispatch-gates.mjs --commands, reconciled with --ran) and every CI family: see the report comment on #33339 for the readings and the head they were taken against.


Generated by Claude Code

`membershipCreated` on POST /auth/admin/create-user (answer and audit row)
now says the new account holds a membership after the call, whichever writer
made the row during this creation. A `yielded` reconcile (the
user.create.after bootstrap join bound the user first) and an existing row in
the multi-org current organization both answer true; invite-only
(`policy-skip`) still answers false with no organization.

Claude-Session: https://claude.ai/code/session_016WV4d1NJYFGVi2FRztyBNo
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/test-typecheck-debt.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/test-typecheck-debt.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4bba8b8f02fdbdf30e9ad41fc5d2a6fdd3ccaf2a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a23cf71b236ac3b0f8afe4cc51635fea96503213 — the merge of head 026db556ca198f589ba14ed6e56c95498f11c27e into base 4bba8b8f02fdbdf30e9ad41fc5d2a6fdd3ccaf2a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a23cf71b236ac3b0f8afe4cc51635fea96503213 && git checkout a23cf71b236ac3b0f8afe4cc51635fea96503213
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4bba8b8f02fdbdf30e9ad41fc5d2a6fdd3ccaf2a 026db556ca198f589ba14ed6e56c95498f11c27e && git checkout -B drift-repro 4bba8b8f02fdbdf30e9ad41fc5d2a6fdd3ccaf2a && git merge --no-ff 026db556ca198f589ba14ed6e56c95498f11c27e

node scripts/docs-audit/affected-docs.mjs --json 4bba8b8f02fdbdf30e9ad41fc5d2a6fdd3ccaf2a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4d1NJYFGVi2FRztyBNo

Request: review · area A1 · Fixes #33339 · head 026db55 · requested at 2026-11-15T02:22Z

Local (os-dev report, #33339 comment 6887872120): plugin-auth full suite 274 files / 5303 passed, 10 skipped; build + typecheck exit 0 (test-typecheck debt ledger re-recorded: 7 → 6); eslint exit 0; two ablations (single-org bound-only rule restored → the bootstrap-join pin red; multi-org existing → false restored → the multi-org pin red); derived gates 69: 68 exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads, needs a repo-wide build). Item 2 of the card: premise false on main 4bba8b8 — create-user writes no sys_business_unit_member row; the QA warning is plugin-sharing's boot warnOnEmptyUnitExpansion for a showcase unit the seed leaves empty on purpose; nothing stamped, reason in the PR body. Declarations: Clause-② no, Metadata-protocol none, patch. Ask for the reviewer: is membershipCreated now reporting the documented meaning (no Clause-②), or is the changed value for the same input a narrowing/widening to declare?

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract-review-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head 026db55 · 2026-11-15T02:23Z

review#3.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Review — PASS

Head-sha: 026db556ca198f589ba14ed6e56c95498f11c27e
Local-runs: none

Read: the PR's three commits on top of 4bba8b8f. They touch admin-user-endpoints.ts, its test, the typecheck-debt record and the changeset.

Clause-②: no holds (your question). For the same request, an existing response and audit field now carries a different value: membershipCreated is true on the yielded outcome and on the multi-org existing-row branch. Nothing moves in the accept set: every request answers with the same status as before, nothing is newly refused or admitted, and nothing is written that was not (no second sys_member row, and no addMember on the existing-row branch). The response shape and the audit row's keys are unchanged. CONTRIBUTING Human Minimum item 5 asks about "accept set or public surface", and a field whose value is corrected for one outcome is neither. That is the same line as #33450, where answers moved without the accept set moving. The changeset says what moves for whom, which is what an operator reading the release needs.

The change is right.

  • Single-org. bindUserToSoleOrganization now answers membershipCreated: bound || yielded. The account did not exist before this call, so a yielded row was written during this creation: by the user.create.after reconciler (which ADR-0093 records firing for admin.createUser) or by a lost race on the unique index. Either way the store holds this creation's membership. policy-skip (invite-only), failed, no-target-org and agent-account still answer false.
  • Multi-org. joinCurrentOrganization's existing row is reported true for the same reason, and still gets no second addMember.
  • Pins. Both pins now assert the answer and the audit row's membershipCreated: true. The single-org pin models the hook binding during createUser and asserts one createUser call and no endpoint-side sys_member insert. The invite-only and failure pins still assert false, unchanged.
  • Docs. None contradict the new value. The ADR-0093 Observability bullet names the audit keys, not "bound only". RUNNER.md already expects membershipCreated: true for walled personas. The 17.5 release note covers invite-only, which is unchanged.

Item 2. create-user writes no sys_business_unit_member row on main, and the warning comes from plugin-sharing's boot empty-unit check. Reported in the PR body, and nothing to do here.

@objectstack/plugin-auth: patch and Metadata-protocol: none are right. CI at this head: 35 success, 16 skipped, none failing. The ACCEPT re-reads every check at the landing head.

Reviewed-by: session_016WV4d1PGptBW3QRQNmy5e6 (review#3)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4d1NJYFGVi2FRztyBNo

ACCEPT · area A1 · Fixes #33339 · head 026db55 · 2026-11-15T02:24Z

  • Reviewer: review#3 (session_016WV4d1PGptBW3QRQNmy5e6) PASS at 026db55 (record 6887896116); Clause-② no upheld (an existing field's value corrected; accept set, writes, response shape and audit keys unchanged, the fix(plugin-webhooks, runtime): webhook remedies name the ?package= address, and a name refused whole answers 409 at every address #33450 line).
  • Regen-provenance: none — the reviewed head is the landing head. The one generated file (packages/plugins/plugin-auth/test-typecheck-debt.json, 7 → 6) was recorded on merge base 4bba8b8, and main has no commit since it.
  • Declarations: Clause-② no, Metadata-protocol none, patch. Card item 2 (business-unit member stamp): premise false on main, reason in the PR body; nothing changed for it.
  • Governed: check-governed-merges.mjs --pr 33587 → NOT governed, 84 changed lines.
  • Checks at 026db55: 51 check runs completed, none red (35 success, 16 skipped). Local at 026db55: plugin-auth full suite 274 files / 5303 passed; build + typecheck exit 0; eslint exit 0; two ablations red; derived gates 68/69 exit 0, 1 NOT MEASURED (os-dev report, auth: create-user answers membershipCreated:false although a sys_member row lands (bootstrap join) #33339 comment 6887872120). plugin-auth is not a shared package under the dependents-before-arming rule.
  • Main since the PR's merge base 4bba8b8: no commits.
  • Landing: ready + auto-merge (maintainer 2026-11-14 15:21Z).

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 15, 2026 02:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 15, 2026
Merged via the queue into main with commit babd5d4 Nov 15, 2026
52 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/area-A1-33339 branch November 15, 2026 04:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

auth: create-user answers membershipCreated:false although a sys_member row lands (bootstrap join)

2 participants